SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
6 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
8.9 KB · 187 lines tsx
Raw Blame History
1import type { SourceRef } from "@dci/core";2import type { Metadata } from "next";3import Link from "next/link";4import { notFound } from "next/navigation";5import { Breadcrumbs } from "@/components/entity/breadcrumbs";6import { JsonLd } from "@/components/entity/json-ld";7import { ExtLink, KeyValueList } from "@/components/entity/kv";8import { SectionNav } from "@/components/entity/section-nav";9import { SourcesFooter } from "@/components/entity/sources-footer";10import { CoverageBars } from "./coverage-bars";11import { Badge } from "@/components/ui/badge";12import { Button } from "@/components/ui/button";13import { Page, Section } from "@/components/ui/card";14import { Metric, MetricCell, MetricRow } from "@/components/ui/metric";15import { ErrorState, OfflineNotice } from "@/components/ui/states";16import { api } from "@/lib/api";17import { fmtDate, fmtInt } from "@/lib/format";18import { ENTITY_TYPE_LABEL, SOURCE_KIND_LABEL } from "@/lib/labels";19import { routes } from "@/lib/routes";20import { NOT_FOUND_METADATA, absoluteUrl, compactJsonLd, pageMetadata } from "@/lib/seo";2122export const revalidate = 300;2324type Params = Promise<{ id: string }>;2526export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {27  const { id } = await params;28  const r = await api.source(id);29  if (!r.ok) return r.status === 404 ? NOT_FOUND_METADATA : { title: "Source" };30  const s = r.data;31  return pageMetadata({32    title: s.name,33    description: `${s.name} (${SOURCE_KIND_LABEL[s.kind].toLowerCase()} source, ${s.domain}) — ${fmtInt(s.documents)} documents, ${fmtInt(s.provenanceRows)} provenance rows across ${fmtInt(s.facilities)} facilities in DataCenterIndex.`,34    path: routes.source(s.id),35  });36}3738function humanise(s: string): string {39  return s.replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase());40}4142export default async function SourcePage({ params }: { params: Params }) {43  const { id } = await params;44  const r = await api.source(id);45  if (!r.ok && r.status === 404) notFound();46  if (!r.ok) {47    return (48      <Page className="py-6">49        <Breadcrumbs items={[{ name: "Sources", href: routes.sources() }, { name: id, href: routes.source(id) }]} />50        <OfflineNotice detail={r.error} className="my-4" />51        <ErrorState />52      </Page>53    );54  }55  const s = r.data;56  const ref: SourceRef = { id: s.id, name: s.name, domain: s.domain, kind: s.kind, url: s.url, license: s.license, attribution: s.attribution };57  const robots = s.robotsAllowed === true ? { tone: "ok" as const, label: "robots.txt allows" } : s.robotsAllowed === false ? { tone: "warn" as const, label: "robots.txt disallows (API endpoint — see notes)" } : { tone: "neutral" as const, label: "robots.txt not evaluated" };58  const entityRows = s.byEntityType.map((e) => ({ label: ENTITY_TYPE_LABEL[e.entityType] ?? humanise(e.entityType), value: e.count }));59  const pageRows = s.byPageType.map((p) => ({ label: humanise(p.pageType), value: p.count }));60  const nav = [61    { id: "licence", label: "Licence" },62    { id: "notes", label: "Notes", count: s.notes ? 1 : 0 },63    { id: "coverage", label: "Coverage", count: entityRows.length + pageRows.length },64    { id: "connector", label: "Connector" },65    { id: "facilities", label: "Facilities" },66  ];6768  return (69    <Page>70      <Breadcrumbs items={[{ name: "Sources", href: routes.sources() }, { name: s.name, href: routes.source(s.id) }]} className="pt-3" />71      <JsonLd data={compactJsonLd({ "@context": "https://schema.org", "@type": "Organization", "@id": `${absoluteUrl(routes.source(s.id))}#org`, name: s.name, url: s.url ?? `https://${s.domain}`, description: s.attribution })} />7273      <header className="flex flex-col gap-2 py-5 sm:py-7">74        <div className="label-caps">Source</div>75        <h1 className="text-[22px] font-semibold leading-tight tracking-[-0.02em] text-ink sm:text-[28px]">{s.name}</h1>76        <div className="flex flex-wrap items-center gap-2 text-[13px] text-ink-2">77          <Badge tone="outline">{SOURCE_KIND_LABEL[s.kind]}</Badge>78          <Badge tone={robots.tone} size="sm">79            {robots.label}80          </Badge>81          <span className="text-ink-4" aria-hidden>82            ·83          </span>84          <ExtLink href={`https://${s.domain}`}>{s.domain}</ExtLink>85          {s.url && (86            <>87              <span className="text-ink-4" aria-hidden>88                ·89              </span>90              <ExtLink href={s.url}>Licence / terms</ExtLink>91            </>92          )}93          <span className="figure text-[11.5px] text-ink-4" title="Authority rank used when values conflict (lower = higher authority)">94            priority {s.priority}95          </span>96        </div>97      </header>9899      <MetricRow cols={5}>100        <MetricCell>101          <Metric value={fmtInt(s.documents)} label="Documents" size="sm" />102        </MetricCell>103        <MetricCell>104          <Metric value={fmtInt(s.provenanceRows)} label="Provenance rows" size="sm" />105        </MetricCell>106        <MetricCell>107          <Metric value={fmtInt(s.facilities)} label="Facilities backed" size="sm" />108        </MetricCell>109        <MetricCell>110          <Metric value={fmtDate(s.lastDocumentAt)} label="Last document" size="sm" />111        </MetricCell>112        <MetricCell>113          <Metric value={fmtDate(s.updatedAt)} label="Registry updated" size="sm" />114        </MetricCell>115      </MetricRow>116117      <SectionNav items={nav} className="mt-4" />118119      <div className="mt-6 flex flex-col gap-8">120        <Section id="licence" className="scroll-mt-28" title="Licence & attribution" description="Recorded per source; the attribution line is shown on every page that uses a value from this source.">121          <KeyValueList122            items={[123              { label: "Licence", value: s.license ?? <span className="text-ink-3">Not recorded — facts only are extracted; the source&rsquo;s own terms apply.</span>, keepEmpty: true },124              { label: "Attribution", value: s.attribution ?? <span className="text-ink-3">{`Source: ${s.name}`}</span>, keepEmpty: true },125              { label: "Terms URL", value: s.url ? <ExtLink href={s.url} /> : null },126            ]}127          />128        </Section>129130        {s.notes && (131          <Section id="notes" className="scroll-mt-28" title="Notes" description="Quirks recorded by the connector author (access method, limits, robots exceptions).">132            <p className="max-w-3xl whitespace-pre-line text-[13.5px] leading-relaxed text-ink-2">{s.notes}</p>133          </Section>134        )}135136        {(entityRows.length > 0 || pageRows.length > 0) && (137          <Section id="coverage" className="scroll-mt-28" title="Coverage" description="Current provenance rows by entity type, and archived documents by page type.">138            <div className="grid gap-6 md:grid-cols-2">139              {entityRows.length > 0 && (140                <div>141                  <div className="label-caps mb-2">Provenance rows by entity</div>142                  <CoverageBars items={entityRows} />143                </div>144              )}145              {pageRows.length > 0 && (146                <div>147                  <div className="label-caps mb-2">Documents by page type</div>148                  <CoverageBars items={pageRows} />149                </div>150              )}151            </div>152          </Section>153        )}154155        <Section id="connector" className="scroll-mt-28" title="Connector" description="How this source is read.">156          <KeyValueList157            items={[158              { label: "Connector id", value: <span className="figure">{s.connectorId}</span> },159              { label: "Kind", value: SOURCE_KIND_LABEL[s.kind] },160              { label: "robots.txt", value: robots.label },161              {162                label: "Pipeline",163                value: (164                  <Link href={`${routes.methodology()}#pipeline`} className="underline decoration-hair-2 hover:text-accent">165                    discover → fetch → archive → extract → normalize → reconcile → publish166                  </Link>167                ),168              },169            ]}170          />171        </Section>172173        <Section id="facilities" className="scroll-mt-28" title="Facilities backed by this source" description={`${fmtInt(s.facilities)} facilities carry at least one current value observed here.`}>174          <p className="max-w-2xl text-[13px] text-ink-2">The public index does not filter facilities by source yet. Browse the facilities index — each facility page lists, field by field, which source backs which value, with first/last observation dates and a link to the page it was read from.</p>175          <div className="mt-3">176            <Button href={routes.datacenters()} size="sm">177              Browse facilities178            </Button>179          </div>180        </Section>181      </div>182183      <SourcesFooter sources={[ref]} updatedAt={s.updatedAt} note="Licence and attribution are recorded per source and shown wherever its values appear." />184    </Page>185  );186}187